feat(branches): move a branch to a different project - #919
Conversation
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ed0b95efd9
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| // sessions whose link to the branch runs through something this | ||
| // query would otherwise have to enumerate. The suffix is preserved | ||
| // so a session rooted at a repo subpath keeps it. | ||
| let old_prefix = format!("{}/", wd.old_path); |
There was a problem hiding this comment.
Use platform separators when rewriting session paths
On Windows, sessions.working_dir is stored from Path::to_string_lossy(), so subdirectories under a worktree use \ separators. This prefix only matches <old>/, meaning sessions rooted under a moved worktree (for example a repo subpath session) are not rewritten by the instr(working_dir, ?5) branch and will continue pointing at the old path after the worktree and workdirs row move. Build the prefix with the platform separator or use path-aware matching before updating the stored working dirs.
Useful? React with 👍 / 👎.
Adds a "Move to Project…" item to the branch card's `…` menu. It opens a searchable project picker and re-parents the branch — with its notes, commits, reviews, sessions and images — into the chosen project. No schema change is needed: every branch-scoped table references the branch through `branch_id` and follows it for free. Only three rows carry a `project_id` of their own, so `store::move_branch_to_project` rewrites them in one transaction — the branch, its `project_repos` row, its `workdirs` row — plus `images.project_id` and the `sessions.working_dir` snapshots rooted under the old worktree. `UPDATE`s only: the `AFTER DELETE` triggers GC sessions, so delete-and-reinsert would destroy transcripts. The `project_repos` row is N:1, so it travels only when the moved branch is the last one on it; otherwise a clone lands in the destination and the siblings keep theirs. A branch with a NULL `project_repo_id` gets a row materialized rather than carried across, where the `resolve_branch_repo_slug` fallback would resolve to the *destination's* primary repo. Both projects then re-elect a primary and re-sync their denormalized `github_repo`, following `remove_project_repo`. On disk, the worktree relocates through a new `git worktree move` wrapper — a plain rename would leave the gitfile and the repo's `gitdir` pointer dangling — from wherever `workdirs.path` says it is, so legacy-layout worktrees move correctly. A failed transaction moves it back. Image files follow per-entry and tolerantly; the diff cache is dropped and rebuilds lazily. Preconditions are checked before anything mutates: both projects must be local (remote branches share one Blox workspace per project), the destination must not already have the branch's repo + subpath, and no session may be running on the branch. The dialog states the first two as a disabled Move button with the reason, using the same NULL-vs-empty subpath key as `idx_project_repos_unique`, and waits on the target's lazily-hydrated repos before trusting the duplicate check. Verified with `just ci`: fmt, clippy, svelte-check, 728 Rust and 644 frontend tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Resolves the review on de6de4f. The "no session is running on this branch" precondition was checked at plan time with nothing held through the mutation, so a session starting between the check and the `git worktree move` — or a second move for the same branch dispatched from the web router — raced the rename. Planning now happens twice: a pre-flight that refuses an impossible move before any of the branch's actions are stopped for it, then again inside `apply_branch_move`, which holds the branch's session launch lock across the re-check, the rename and the transaction. That is the lock `prs` already takes around its queue-or-start decisions, and holding it means the whole mutation runs synchronously on a blocking thread rather than around an await. When the transaction fails and the worktree can't be moved back, the returned error now says where the worktree actually is: reporting only the store error read as "nothing changed" while the tree sat where neither project expects it. The crash window between the rename and the commit is documented rather than closed — `new_path` is derived rather than stored, so re-running the move finds the worktree already sitting where the transaction is about to record it, and completes. `is_unique_violation` matched on SQLite's message text alone, so a future error that merely mentioned `idx_project_repos_unique` would have been rewritten into a confident "the destination already has this repository"; it now requires `ErrorCode::ConstraintViolation` first, with a test that an unrelated failure naming the index keeps its own words. In the dialog, `highlightedIndex` is derived from the selection instead of tracked beside it, and the clamping moves into a tested helper. Arrowing down a long list, narrowing it with a query and then pressing ArrowUp left the index past the end of the new list, where `selectProject` received `undefined` and threw; clicking a row likewise left the index stale for the next arrow press. Verified with `just ci`: fmt, clippy, svelte-check, 729 Rust and 648 frontend tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
The move-to-project dialog used the stock shadcn dialog chrome — stacked header, default footer, transparent input — while every other modal (new session, note, session) shares a house style. It now follows NewSessionModal: a flush header bar with the title and a ghost X close button over a border, an 18px-padded body, and an outline Cancel next to an accent Move button. The search field and the project list sit on var(--bg-primary) — white in light mode, the same surface as the session prompt editor — with a var(--border-muted) border, instead of disappearing into the gray card. The input's focus ring is swapped for the editor's border-emphasis treatment, and on mobile the list stretches to fill the full-screen dialog rather than capping at the desktop height. Verified with prettier, svelte-check (0 errors) and 648 frontend tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
The move dialog's project rows showed a plain name over full repo-path labels, so the same projects read differently than they do in the sidebar, which introduces each one with a state icon and colored repo badges. The sidebar row's identity block — the PR-status or cloud icon on the left, the bold name, and the badge/activity meta line with its repo-count fallback — now lives in a shared ProjectRowContent component that both the sidebar and MoveBranchDialog render. It derives everything from the shared stores given only the project, so a picker row shows the same live state the sidebar does. The dialog needed nothing new to feed it: opening it already hydrates every project's branches and repos for the duplicate-repo check, and hydration is also what materializes the repo badges. The sidebar's active-row brightening reached its meta text through scoped descendant selectors, which can't cross into the child, and a :global svg override would tie with the child's status-icon colors at equal specificity, leaving the winner to stylesheet order. The meta color is now a --project-row-meta-color custom property the child reads, and the stroke override names the two places that still need it — the row's status spinners and the All Repos icon — where its higher specificity wins deterministically. Verified with prettier, svelte-check (0 errors, 0 warnings) and 648 frontend tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Moving a branch already announces itself: the dialog closes and the branch card leaves the source project's list as both ends refresh. The "Branch moved to …" success toast on top of that was requested removed, so handleMoveBranch now just refreshes both projects and invalidates the timeline. Failures still surface in the dialog's error line. Verified with prettier, svelte-check (0 errors) and 648 frontend tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Resolves the Codex P2 on the branch move's session rewrite.
move_branch_to_project re-roots sessions.working_dir by matching a
hard-coded "{old_path}/" prefix, but working dirs are stored via
Path::to_string_lossy, so on Windows the separator PathBuf::join
inserts after the worktree root is a backslash. A session rooted at a
repo subpath there never matched, and kept pointing into the old
worktree after the workdirs row and the tree itself had moved.
The query now takes its prefixes from a tested helper: on Windows both
"{old_path}\" for joined paths and "{old_path}/" for paths that
arrived as strings through the web router; on Unix the pair collapses
to "/" alone, because a backslash is an ordinary filename character
there and matching it would move a sibling directory that merely has
one in its name — a test pins that sibling in place. The
suffix-preserving substr needs no change: it starts at the separator
character itself, so a rewritten path keeps whichever separator it was
recorded with.
Verified with cargo fmt, cargo clippy -D warnings, and 731 Rust tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
d2b90de to
193864b
Compare
Adds a Move to Project… item to the branch card's
…menu. It opens a searchable project picker and re-parents the branch — with its notes, commits, reviews, sessions and images — into the chosen project.Store
No schema change is needed: every branch-scoped table references the branch through
branch_idand follows it for free. Only three rows carry aproject_idof their own, sostore::move_branch_to_projectrewrites them in one transaction — the branch, itsproject_reposrow, itsworkdirsrow — plusimages.project_idand thesessions.working_dirsnapshots rooted under the old worktree.UPDATEs only: theAFTER DELETEtriggers GC sessions, so delete-and-reinsert would destroy transcripts.The
project_reposrow is N:1, so it travels only when the moved branch is the last one on it; otherwise a clone lands in the destination and the siblings keep theirs. A branch with a NULLproject_repo_idgets a row materialized rather than carried across, where theresolve_branch_repo_slugfallback would resolve to the destination's primary repo. Both projects then re-elect a primary and re-sync their denormalizedgithub_repo, followingremove_project_repo.On disk
The worktree relocates through a new
git worktree movewrapper — a plain rename would leave the gitfile and the repo'sgitdirpointer dangling — from whereverworkdirs.pathsays it is, so legacy-layout worktrees move correctly. A failed transaction moves it back, and reports where the worktree actually ended up if it can't. Image files follow per-entry and tolerantly; the diff cache is dropped and rebuilds lazily.Preconditions and concurrency
Both projects must be local (remote branches share one Blox workspace per project), the destination must not already have the branch's repo + subpath, and no session may be running on the branch. Planning happens twice: a pre-flight that refuses an impossible move before any of the branch's actions are stopped for it, then again inside
apply_branch_move, which holds the branch's session launch lock across the re-check, the rename and the transaction — the same lockprstakes around its queue-or-start decisions. That keeps a session from starting between the check and thegit worktree move, and serializes two moves of the same branch dispatched from the Tauri command and the web router.Dialog
The picker states the first two preconditions as a disabled Move button with the reason, using the same NULL-vs-empty subpath key as
idx_project_repos_unique, and waits on the target's lazily-hydrated repos before trusting the duplicate check. Keyboard navigation is derived from the selection through a tested helper, so narrowing the list with a query can't leave the highlight past its end.Styling follows
NewSessionModal— flush header bar with a ghost X, 18px body, outline Cancel next to an accent Move — and the project rows are the sidebar's own row, extracted into a sharedProjectRowContentcomponent, so a picker row shows the same status icon, repo badges and live activity the sidebar does.Verification
just ci: fmt, clippy, svelte-check (0 errors, 0 warnings), 729 Rust and 648 frontend tests.